← Back to zero index

Error Handling Patterns That Actually Work in Production

Real-world error handling strategies I learned from debugging production issues - patterns that save you hours of troubleshooting

The Pain of Production Errors

After my first production incident where a single unhandled error brought down an entire service, I realized error handling isn't just about try-catch blocks. It's about building resilient systems that fail gracefully and give you the context you need to fix issues quickly.

The Three Layers of Error Handling

Layer 1: User-Facing Errors

Users should never see technical errors. Always provide friendly, actionable messages:

// Bad - users see stack traces
async function fetchUserData(userId) {
  const response = await fetch(`/api/users/${userId}`)
  const data = await response.json()
  return data
}

// Good - graceful degradation
async function fetchUserData(userId) {
  try {
    const response = await fetch(`/api/users/${userId}`)

    if (!response.ok) {
      if (response.status === 404) {
        return { error: "User not found", data: null }
      }
      if (response.status >= 500) {
        return {
          error: "Service temporarily unavailable. Please try again later.",
          data: null,
        }
      }
      throw new Error(`HTTP ${response.status}`)
    }

    const data = await response.json()
    return { error: null, data }
  } catch (error) {
    // Log for debugging, return user-friendly message
    console.error("Failed to fetch user:", error)
    return {
      error: "Unable to load user information. Please refresh the page.",
      data: null,
    }
  }
}

Layer 2: Application-Level Error Boundaries

In React, use error boundaries to catch component errors:

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props)
    this.state = { hasError: false, error: null, errorInfo: null }
  }

  static getDerivedStateFromError(error) {
    return { hasError: true }
  }

  componentDidCatch(error, errorInfo) {
    // Log to your error tracking service
    this.setState({
      error,
      errorInfo,
    })

    // Send to monitoring service
    logErrorToService(error, errorInfo)
  }

  render() {
    if (this.state.hasError) {
      return (
        <div className="error-boundary">
          <h2>Something went wrong</h2>
          <details style={{ whiteSpace: "pre-wrap" }}>
            {this.state.error && this.state.error.toString()}
            <br />
            {this.state.errorInfo.componentStack}
          </details>
        </div>
      )
    }

    return this.props.children
  }
}

// Usage - wrap critical sections
;<ErrorBoundary>
  <Dashboard />
</ErrorBoundary>

Layer 3: Monitoring and Alerting

Catch errors before users report them:

// Error tracking service wrapper
class ErrorTracker {
  static init() {
    // Initialize your service (Sentry, LogRocket, etc.)
    this.client = new Sentry.Client({ dsn: process.env.SENTRY_DSN })
  }

  static captureException(error, context = {}) {
    this.client.captureException(error, {
      tags: context.tags || {},
      extra: {
        userId: context.userId,
        url: context.url,
        timestamp: new Date().toISOString(),
        ...context.extra,
      },
    })
  }

  static captureMessage(message, level = "info", context = {}) {
    this.client.captureMessage(message, {
      level,
      tags: context.tags || {},
      extra: context.extra || {},
    })
  }
}

// Usage in async functions
async function processOrder(orderId) {
  try {
    const order = await fetchOrder(orderId)
    await processPayment(order)
    await sendConfirmation(order)
  } catch (error) {
    ErrorTracker.captureException(error, {
      tags: { feature: "order-processing", severity: "high" },
      extra: { orderId, userId: order.userId },
    })

    // Re-throw to let caller handle
    throw new ProcessingError("Order processing failed", { cause: error })
  }
}

Custom Error Classes for Better Debugging

Create domain-specific error types:

// Base error class
class AppError extends Error {
  constructor(message, { statusCode = 500, code, details, cause } = {}) {
    super(message)
    this.name = this.constructor.name
    this.statusCode = statusCode
    this.code = code
    this.details = details
    this.cause = cause
    Error.captureStackTrace(this, this.constructor)
  }

  toJSON() {
    return {
      name: this.name,
      message: this.message,
      code: this.code,
      statusCode: this.statusCode,
      details: this.details,
    }
  }
}

// Specific error types
class ValidationError extends AppError {
  constructor(message, details) {
    super(message, {
      statusCode: 400,
      code: "VALIDATION_ERROR",
      details,
    })
  }
}

class NotFoundError extends AppError {
  constructor(resource, id) {
    super(`${resource} not found`, {
      statusCode: 404,
      code: "NOT_FOUND",
      details: { resource, id },
    })
  }
}

class AuthorizationError extends AppError {
  constructor(action, resource) {
    super(`Not authorized to ${action} ${resource}`, {
      statusCode: 403,
      code: "UNAUTHORIZED",
      details: { action, resource },
    })
  }
}

class RateLimitError extends AppError {
  constructor(limit, retryAfter) {
    super("Rate limit exceeded", {
      statusCode: 429,
      code: "RATE_LIMIT",
      details: { limit, retryAfter },
    })
  }
}

// Usage
function validateUserInput(data) {
  if (!data.email) {
    throw new ValidationError("Email is required", { field: "email" })
  }

  if (!isValidEmail(data.email)) {
    throw new ValidationError("Invalid email format", {
      field: "email",
      value: data.email,
    })
  }
}

// Error handler middleware
function errorHandler(err, req, res, next) {
  // Log error
  console.error("Error:", err)

  // Send appropriate response
  if (err instanceof AppError) {
    return res.status(err.statusCode).json(err.toJSON())
  }

  // Unknown errors - don't expose details
  return res.status(500).json({
    error: "Internal server error",
    code: "INTERNAL_ERROR",
  })
}

Retry Patterns with Exponential Backoff

Network errors are often transient. Implement smart retries:

class RetryableError extends Error {
  constructor(message, { retryable = true, retryAfter } = {}) {
    super(message)
    this.retryable = retryable
    this.retryAfter = retryAfter
  }
}

async function retryWithBackoff(fn, options = {}) {
  const {
    maxRetries = 3,
    initialDelay = 1000,
    maxDelay = 10000,
    backoffFactor = 2,
    retryable = error => error instanceof RetryableError,
  } = options

  let lastError
  let delay = initialDelay

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await fn()
    } catch (error) {
      lastError = error

      // Don't retry if not retryable or on last attempt
      if (!retryable(error) || attempt === maxRetries) {
        throw error
      }

      // Wait before retrying
      await new Promise(resolve => setTimeout(resolve, delay))

      // Exponential backoff with jitter
      delay = Math.min(delay * backoffFactor, maxDelay)
      delay = delay + Math.random() * 1000 // Add jitter
    }
  }

  throw lastError
}

// Usage
async function fetchWithRetry(url) {
  return retryWithBackoff(
    async () => {
      const response = await fetch(url)

      if (response.status >= 500) {
        throw new RetryableError("Server error", { retryAfter: 5000 })
      }

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`) // Don't retry client errors
      }

      return response.json()
    },
    {
      maxRetries: 3,
      initialDelay: 1000,
    }
  )
}

Circuit Breaker Pattern

Prevent cascading failures:

class CircuitBreaker {
  constructor(fn, options = {}) {
    this.fn = fn
    this.threshold = options.threshold || 5
    this.timeout = options.timeout || 60000
    this.failureCount = 0
    this.state = "CLOSED" // CLOSED, OPEN, HALF_OPEN
    this.nextAttempt = Date.now()
  }

  async call(...args) {
    if (this.state === "OPEN") {
      if (Date.now() < this.nextAttempt) {
        throw new Error("Circuit breaker is OPEN")
      }
      this.state = "HALF_OPEN"
    }

    try {
      const result = await this.fn(...args)
      this.onSuccess()
      return result
    } catch (error) {
      this.onFailure()
      throw error
    }
  }

  onSuccess() {
    this.failureCount = 0
    this.state = "CLOSED"
  }

  onFailure() {
    this.failureCount++

    if (this.failureCount >= this.threshold) {
      this.state = "OPEN"
      this.nextAttempt = Date.now() + this.timeout
    }
  }
}

// Usage
const fetchUser = new CircuitBreaker(
  async userId => {
    const response = await fetch(`/api/users/${userId}`)
    if (!response.ok) throw new Error("Failed to fetch")
    return response.json()
  },
  {
    threshold: 5,
    timeout: 30000,
  }
)

// All calls go through circuit breaker
try {
  const user = await fetchUser.call(123)
} catch (error) {
  // Circuit breaker prevented further calls
}

Error Context Collection

Collect context automatically:

function withErrorContext(fn, context = {}) {
  return async (...args) => {
    try {
      return await fn(...args)
    } catch (error) {
      // Attach context to error
      error.context = {
        ...context,
        timestamp: new Date().toISOString(),
        args: args.map(arg => {
          // Don't log sensitive data
          if (arg && typeof arg === "object" && arg.password) {
            return { ...arg, password: "[REDACTED]" }
          }
          return arg
        }),
      }

      throw error
    }
  }
}

// Usage
const processPayment = withErrorContext(
  async (orderId, amount) => {
    // Payment processing logic
  },
  { feature: "payment", version: "2.0" }
)

What I Learned

  1. Always have three layers: User-facing, application, and monitoring
  2. Use custom error types: Makes debugging 10x easier
  3. Implement retries: Most errors are transient
  4. Use circuit breakers: Protect your system from cascading failures
  5. Collect context: The more context you have, the faster you fix bugs
  6. Never expose internals: Users shouldn't see stack traces or database errors

The key insight: Error handling isn't about preventing errors—it's about handling them gracefully and learning from them.

Documenting systems and foundational patterns in public. Found this insightful? Have thoughts? Let's connect via email or on X / Twitter.